package com.cty.nio;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Scanner;

/**
 * @Auther: cty
 * @Date: 2020/4/17 18:07
 * @Description: NIO客户端
 * @version: 1.0
 */
public class NIOClient2 implements Runnable{
    String host;
    int port;
    // 定义多路复用器（选择器），用于注册通道
    private Selector selector;
    // 定义一个客户通道
    SocketChannel socketChannel;
    // 定义两块缓存，分别用于读和写（单位字节）
    private ByteBuffer readBuffer = ByteBuffer.allocate(1024);
    private ByteBuffer writeBuffer = ByteBuffer.allocate(1024);

    // 构造器
    public NIOClient2(String host, int port) throws IOException {
        socketChannel = SocketChannel.open();  // 开启通道
        socketChannel.configureBlocking(false);  // 将通道设置为非阻塞模式
        this.selector = Selector.open();  // 开启多路复用器
        socketChannel.register(this.selector, SelectionKey.OP_CONNECT);  // 将客户通道注册到多路复用器，并设置为准备连接状态
        /**
         * 方法：SocketChannel . boolean connect(SocketAddress remote)
         * 参数：通道将要连接的远程地址-remote
         * 返回：是否完成与一个远程套接字通道的连接 true or false
         * 作用：指定通道要连接的远程地址
         * 说明：
         *      该方法受通道阻塞模式影响，若当前线程调用该方法时：
         *          若通道处于非阻塞模式
         *              若立刻与远程端建立连接，返回true；当远程端是本地连接（即在同一个局域网下）时可能会出现该情况
         *              若没有与远程端建立连接，返回false；之后必须手动调用finishConnect方法完成连接
         *          若通道处于阻塞模式
         *              会一直等待，直到连接成功返回true，或发生IO错误
         *
         *      对于非阻塞模式，会先调用connect()方法，无论是否与远程套接字通道建立连接，通道都会变成“可连接”状态
         *      ，然后会再调用finishConnect()方法完成连接。
         *
         *      如果该方法正在执行时该通道中的读写方法被调用，读写方法必须等待该方法执行结束才能开始执行。
         */
        boolean isConnected = socketChannel.connect(new InetSocketAddress(host, port));  // 连接远程服务器
        System.out.println("isConnected: "+isConnected);
    }

    // 主函数
    public static void main(String[] args) throws IOException {
        new Thread(new NIOClient2("localhost", 9999)).start();
    }

    @Override
    public void run() {
        while(true){
            try {
                // 注册到多路复用器，且可进行IO操作的通道将被选中
                // 阻塞方法，当至少一个通道被选中，此方法返回，否则一直阻塞
                this.selector.select();
                // 返回选中通道标记集合
                Iterator<SelectionKey> keys = this.selector.selectedKeys().iterator();
                while(keys.hasNext()){
                    SelectionKey key = keys.next();
                    // 将本次要处理的通道从集合中删除，下次循环根据新的通道列表再次执行必要的业务逻辑
                    keys.remove();

                    // 通道是否有效
                    if(key.isValid()){
                        // 通道是否处于可接收客户端连接状态
                        try{
                            if(key.isConnectable()){
                                connect(key);
                            }
                        }catch (Exception e){
                            key.cancel();
                        }

                        // 通道是否处于可读状态
                        try{
                            if(key.isReadable()){
                                read(key);
                            }
                        }catch (Exception e){
                            key.cancel();
                        }

                        // 是否处于可写状态
                        try{
                            if(key.isWritable()){
                                write(key);
                            }
                        }catch (Exception e){
                            key.cancel();
                        }

                    }
                }

            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    private void connect(SelectionKey key){
        try {
            // 此通道是构造方法中注册到Selector上的SocketChannel
            SocketChannel socketChannel = (SocketChannel)key.channel();
            System.out.println("isConnected2: "+socketChannel.isConnected());

            // 阻塞方法，当客户端发起请求后返回
            if(socketChannel.isConnectionPending()){
                System.out.println("ConnectionPending then invoke finishConnect.");
                /**
                 * 方法：SocketChannel . boolean finishConnect()
                 * 参数：无
                 * 返回：是否完成与一个远程套接字通道的连接 true or false
                 * 作用：完成与一个远程套接字通道的连接
                 * 说明：
                 *      该方法受通道阻塞模式影响，若当前线程调用该方法时：
                 *          若通道处于非阻塞模式
                 *              若已经连接成功，则直接返回true
                 *              若未连接成功，则返回false
                 *          若通道处于阻塞模式
                 *              会一直等待，直到连接成功返回true，或抛出失败异常
                 *
                 *      对于非阻塞模式，会先调用connect()方法，无论是否与远程套接字通道建立连接，通道都会变成“可连接”状态
                 *      ，然后会再调用finishConnect()方法完成连接。
                 *
                 *      如果该方法正在执行时该通道中的读写方法被调用，读写方法必须等待该方法执行结束才能开始执行。
                 */
                socketChannel.finishConnect();
            }

            socketChannel.configureBlocking(false);
            // 将对应客户端通道注册到多路复用器，并设置为可写出数据状态
            socketChannel.register(this.selector, SelectionKey.OP_WRITE);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private void read(SelectionKey key){
        try {
            this.readBuffer.clear();  // 清空读缓存
            SocketChannel socketChannel = (SocketChannel)key.channel();  // 获取通道
            System.out.println("Reading from " + socketChannel.getRemoteAddress() + " server: ");
            /**
             * 方法：SocketChannel . int read(ByteBuffer dst)
             * 参数：存放读取数据的缓存-dst
             * 返回：读取数据的字节数
             * 作用：从通道中读取字节序列到给定的缓存中
             * 说明：
             *      该方法受通道阻塞模式影响，若当前线程调用该方法时：
             *          若通道处于非阻塞模式
             *              只能读取到当前通道中数据（哪怕没有数据）并马上返回
             *          若通道处于阻塞模式
             *              会一直等待，直到通道中有数据（哪怕是1字节数据）并读取
             *          若其他线程已经调用该通道中的read()方法
             *              必须排队等待前面的read()方法执行完才能执行
             */
            int readLength = socketChannel.read(readBuffer);  // 将通道中的数据读取到缓存中
            if(readLength == -1){  // 若客户端未在通道中写入数据
                key.channel().close();  // 关闭通道
                key.cancel();  // 关闭连接
                return;
            }
            // 执行到这里，说明客户端在通道中写入数据
            this.readBuffer.flip();  // 重置缓存游标
            // 定义一个字节数据，从读缓存中获取数据。 Buffer.remaining() -> 获取Buffer中的有效字节数
            byte[] bytes = new byte[readBuffer.remaining()];
            readBuffer.get(bytes);
            System.out.println(new String(bytes, "UTF-8"));

            // 将该通道重新注册到多路复用器，并设置为可写出数据状态
            socketChannel.register(this.selector, SelectionKey.OP_WRITE);
        } catch (IOException e) {
//            e.printStackTrace();
            try {
                System.out.println("Connection reset with " + socketChannel.getRemoteAddress() + " server.");
                key.channel().close();  // 关闭通道
                key.cancel();  // 关闭连接
            } catch (IOException ex) {
                ex.printStackTrace();
            }
        }

    }

    private void write(SelectionKey key){
        this.writeBuffer.clear();
        SocketChannel socketChannel = (SocketChannel) key.channel();
        Scanner scanner = new Scanner(System.in);

        try {
            System.out.println("Put message for sending to " + socketChannel.getRemoteAddress() + " server > ");
            String message = scanner.nextLine();
            writeBuffer.put(message.getBytes("UTF-8"));
            writeBuffer.flip();
            /**
             * 方法：SocketChannel . int write(ByteBuffer src)
             * 参数：存放待写出数据的缓存-dst
             * 返回：待写出数据的字节数
             * 作用：将给定的缓存中的数据写入通道
             * 说明：
             *      该方法受通道阻塞模式影响，若当前线程调用该方法时：
             *          若通道处于非阻塞模式
             *              写出的字节数不能超过套接字输出缓存的空闲空间大小，然后马上返回
             *          若通道处于阻塞模式
             *              直到给定缓存中的数据都写入到通道（实际上是写到套接字输出缓存）中才返回
             *          若其他线程已经调用该通道中的write()方法
             *              必须排队等待前面的write()方法执行完才能执行
             */
            socketChannel.write(writeBuffer);

            socketChannel.register(this.selector, SelectionKey.OP_READ);
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        } catch (IOException e) {
//            e.printStackTrace();
            try {
                System.out.println("Connection reset with " + socketChannel.getRemoteAddress() + " server.");
                key.channel().close();  // 关闭通道
                key.cancel();  // 关闭连接
            } catch (IOException ex) {
                ex.printStackTrace();
            }
        }
    }
}
